Skip to content

Feature sparse linalg solvers#2841

Open
abagusetty wants to merge 104 commits into
IntelPython:masterfrom
abagusetty:feature-sparse-linalg-solvers
Open

Feature sparse linalg solvers#2841
abagusetty wants to merge 104 commits into
IntelPython:masterfrom
abagusetty:feature-sparse-linalg-solvers

Conversation

@abagusetty

@abagusetty abagusetty commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Adds support for from dpnp.scipy.sparse.linalg import LinearOperator, cg, gmres, minres
Fixes: #2831

  • Have you provided a meaningful PR description?
  • Have you added a test, reproducer or referred to an issue with a reproducer?
  • Have you tested your changes locally for CPU and GPU devices?
  • Have you made sure that new changes do not introduce compiler warnings?
  • Have you checked performance impact of proposed changes?
  • Have you added documentation for your changes, if necessary?
  • Have you added your changes to the changelog?

abagusetty and others added 30 commits April 2, 2026 12:52
…oneMKL hooks

- _interface.py: add full operator algebra (.H, .T, +, *, **, neg),
  _AdjointLinearOperator, _TransposedLinearOperator, _SumLinearOperator,
  _ProductLinearOperator, _ScaledLinearOperator, _PowerLinearOperator,
  IdentityOperator, MatrixLinearOperator, _AdjointMatrixOperator,
  _CustomLinearOperator factory dispatch; extend aslinearoperator
  to handle dpnp sparse and dense arrays

- _iterative.py: add _make_system (dtype validation, preconditioner
  wiring, working dtype selection); add _make_fast_matvec CSR/oneMKL
  SpMV hook; fix GMRES Arnoldi inner product to single oneMKL BLAS
  gemv (dpnp.dot) instead of slow Python vdot loop; offload
  Hessenberg lstsq to numpy.linalg.lstsq (CPU, matches CuPy);
  fix SciPy host-fallback tol->rtol deprecation via _scipy_tol_kwarg;
  add preconditioner support to CG; keep MINRES as SciPy-backed stub

Refs: CuPy v14.0.1 cupyx/scipy/sparse/linalg/_interface.py,
      cupyx/scipy/sparse/linalg/_iterative.py"
…gmres, minres

Modeled after CuPy's cupyx_tests/scipy_tests/sparse_tests/test_linalg.py.
Covers:
  - LinearOperator: shape, dtype inference, matvec/rmatvec/matmat,
    subclassing, __matmul__, __call__, edge cases
  - aslinearoperator: dense array, duck-type, identity passthrough,
    rmatvec from dense, invalid inputs
  - cg: SPD convergence, scipy reference match, x0 warm start, b_ndim=2,
    callback, atol, LinearOperator path, invalid inputs,
    non-convergence info check
  - gmres: diag-dominant convergence, scipy reference match, restart
    variants, x0, b_ndim=2, callbacks, complex systems, atol,
    non-convergence info check, Hilbert-matrix stress test
  - minres: SPD, symmetric-indefinite, scipy reference, shift parameter,
    non-square guard, LinearOperator path, callback
  - Integration: parametric (n, dtype) cross-solver tests via LinearOperator
  - Import smoke tests: __all__ completeness
- Use dpnp.tests.helper: assert_dtype_allclose, generate_random_numpy_array,
  get_all_dtypes, get_float_complex_dtypes, has_support_aspect64
- Use dpnp.tests.third_party.cupy testing harness (with_requires, etc.)
- Use numpy.testing assert_allclose / assert_array_equal / assert_raises
- Use dpnp.asnumpy() instead of numpy.asarray()
- Use pytest parametrize ids matching existing test conventions
- Use is_scipy_available() helper from tests/helper.py
- Strict class-per-solver organisation matching TestCholesky / TestDet etc.
…or dtype

Two bugs fixed:
1. _init_dtype() was calling dpnp.zeros(n) which defaults to float64,
   so a float32 matvec would upcast and return float64, making the
   inferred dtype wrong.  Fix: use dpnp.zeros(n, dtype=dpnp.int8) as
   SciPy/CuPy do — any numeric matvec will promote int8 to its own dtype.
2. _CustomLinearOperator.__init__ called _init_dtype() even when an
   explicit dtype was already supplied, overwriting the caller's value.
   Fix: _init_dtype() now short-circuits when self.dtype is already set.
…ption handling

Align gemv.cpp with the conventions established in blas/gemm.cpp:

Headers added:
- ext/common.hpp         (dpctl_td_ns, consistent with other extensions)
- utils/memory_overlap.hpp   (MemoryOverlap guard on x vs y)
- utils/output_validation.hpp (CheckWritable + AmpleMemory on y)
- utils/type_utils.hpp       (validate_type_for_device<T> in impl)
- <sstream>                  (needed for stringstream error_msg)

Exception handling added in sparse_gemv_impl():
- try/catch(oneapi::mkl::exception) around all oneMKL sparse calls
- try/catch(sycl::exception) around all oneMKL sparse calls
- release_matrix_handle cleanup in the exception error path
- throw std::runtime_error with descriptive message on catch

Input validation added in sparse_gemv():
- ndim checks: x and y must be 1-D
- queues_are_compatible() across all 5 USM arrays
- MemoryOverlap()(x, y) aliasing guard
- CheckWritable::throw_if_not_writable(y)
- AmpleMemory::throw_if_not_ample(y, num_rows)
- keep_args_alive() at function exit (was missing, returning empty event)
… table

Modeled after blas/gemm.cpp (2-D table: value type x index type) and
blas/gemv.cpp (dispatch vector pattern with ContigFactory + init_dispatch_table).

Changes:
- Add sparse/types_matrix.hpp with SparseGemvTypePairSupportFactory<Tv, Ti>
  encoding the 4 supported combinations: {float32,float64} x {int32,int64}
- Rewrite sparse_gemv_impl() to take typeless char* pointers (matching
  the blas gemv_impl signature style) — type info flows through template
  params only, no runtime branching inside the impl
- Replace the 60-line if/else val_typenum/idx_typenum chain in sparse_gemv()
  with a 2-D dispatch table lookup (gemv_dispatch_table[val_id][idx_id])
- Rename init_sparse_gemv_dispatch_vector -> init_sparse_gemv_dispatch_table
  and implement it via init_dispatch_table<> from ext/common.hpp
- All validation guards and exception handling from prior commit are preserved
…se_gemv_dispatch_table

Follows the rename made in gemv.cpp when the dispatch mechanism was
changed from a 1-D vector to a 2-D table (value type x index type).
All other declarations (sparse_gemv signature, parameters) are unchanged.
The oneMKL 2025-2 sparse BLAS API deprecated the old 8-argument
set_csr_data(queue, handle, nrows, ncols, index_base, row_ptr, col_ind,
values, deps) overload in favour of a new signature that takes the
sparse matrix handle as `spmat` and adds an explicit `nnz` argument:

  set_csr_data(queue, spmat, nrows, ncols, nnz, index_base,
               row_ptr, col_ind, values, deps)

Fixes:
- Replace old set_csr_data call with the new nnz-aware signature
- Silences the resulting -Wunused-parameter warning on `nnz` (now used)
- No functional change; all other logic is unchanged
…tring

Line 477: `hasattr(A, "rmatmat\")` had a Markdown-escaped backslash
leaked into the Python source, causing an unterminated string literal.
Fixed to `hasattr(A, "rmatmat")`.
dpnp.ndarray blocks implicit NumPy conversion via __array__ to prevent
silent dtype=object arrays. All test assertions must use .asnumpy()
to materialize device arrays onto the host explicitly.

Also replaces numpy.asarray(x_dp) in _rel_residual helper.
…dation order

- _iterative.py: raise NotImplementedError for M != None *before* the
  _HOST_N_THRESHOLD SciPy fast-path in cg() and gmres(), so the contract
  is enforced regardless of system size (fixes test_cg_preconditioner_unsupported_raises,
  test_gmres_preconditioner_unsupported_raises).
- _iterative.py: validate callback_type and raise NotImplementedError for
  'pr_norm' *before* the _HOST_N_THRESHOLD branch in gmres(), so small-n
  systems also see the error (fixes test_gmres_callback_type_pr_norm_raises).
- _iterative.py: pass callback_type='legacy' to scipy.sparse.linalg.gmres
  when delegating on the fast path to suppress SciPy DeprecationWarning.
- test_scipy_sparse_linalg.py: add dtype=numpy.float64 to expected arange()
  calls in test_identity_operator and test_gmres_happy_breakdown so strict
  NumPy 2.0 dtype-equality checks pass (float64 result vs int64 expected).
- Replace .asnumpy() method calls with dpnp.asnumpy() module fn
  (asnumpy is not an ndarray method in dpnp; it is a top-level fn)
- Fix dpnp.any(x) ambiguous truth value in x0 zero-check; replace
  with explicit `x0 is not None` guard for r0 initialisation
- Fix V_mat.T.conj() -> dpnp.conj(V_mat.T) in GMRES Arnoldi step
- Guard minres beta sqrt against tiny negative floats: sqrt(abs(...))
- Unify GMRES Hessenberg h_np assignment to avoid .real stripping
  producing wrong dtype for complex systems
- Fix float() cast on dpnp scalar norm inside GMRES inner h_j1 line
…failures)

The committed code used hypot(gbar, oldb) as delta_k which is the
gamma (norm) from the PREVIOUS rotation step, not the correct diagonal
entry from applying the previous Givens rotation to the current column.

The correct Paige-Saunders (1975) two-rotation recurrence is:

  oldeps = epsln
  delta  = cs * dbar + sn * alpha   # apply previous rotation
  gbar_k = sn * dbar - cs * alpha   # residual -> new rotation input
  epsln  = sn * beta
  dbar   = -cs * beta

  gamma = hypot(gbar_k, beta)       # NEW rotation eliminates beta
  cs    = gbar_k / gamma
  sn    = beta   / gamma

  w_new = (v - oldeps*w - delta*w2) / gamma  # three-term update

This matches scipy.sparse.linalg.minres and Choi (2006) eq. 6.11.

The buggy recurrence produced solutions ~1.08x away from the true
solution (rel_err ~1e0) instead of the expected ~1e-13.

Co-authored-by: fix-minres-recurrence
Abhishek Bagusetty and others added 17 commits May 27, 2026 12:33
…ost Hessenberg lstsq

Closes audit items IntelPython#9, IntelPython#15, IntelPython#16, IntelPython#17, IntelPython#19 from the prior solver review.
All four touch the iterative-solver inner loop and were left out of the
previous correctness-only commit because they require a new C++ binding.

backend/extensions/blas/gemv.{cpp,hpp}, blas_py.cpp
  - Extend the typed gemv_impl signature with alpha and beta as
    doubles; the per-T impl casts them to the matrix value type at
    dispatch time. dpnp.dot and other legacy callers are unaffected
    -- the existing gemv() public function now forwards (1.0, 0.0) to
    the shared gemv_dispatch helper.
  - Add gemv_alpha_beta() entry point and a _gemv_alpha_beta pybind
    method computing y = alpha * op(A) * x + beta * y for caller-
    supplied scalars. Required by the GMRES Arnoldi fast path which
    fires gemv with (alpha=1, beta=0) writing into a Hessenberg
    column slice, then (alpha=-1, beta=1) fusing u -= V @ h into
    one kernel. For complex matrices the scalars are always one of
    {-1, 0, 1} and so survive the cast exactly; the impl docstring
    flags the silent imag-loss caveat for any other complex caller.
  - Refactor: hoist the existing validation / strides / dispatch
    plumbing into a static gemv_dispatch helper so both entry points
    share identical behaviour without duplication.

scipy/sparse/linalg/_iterative.py
  - _make_compute_hu now takes both V and H. The closure writes the
    projection coefficients h = V[:, :j+1]^H @ u directly into the
    Hessenberg column slice H[:j+1, j] via a single gemv with the
    output pointing at that slice -- no intermediate h buffer, no
    slice-assign copy (audit item IntelPython#16). Pass 2 fuses the AXPY-style
    update u = -V @ h + 1 * u into one gemv with alpha=-1, beta=1 --
    no tmp buffer, one kernel instead of gemv-plus-subtract (audit
    item IntelPython#19). For complex V the (j+1)-element h slice is conjugated
    in-place between the two passes (V^T -> V^H), negligible cost
    next to the n*(j+1) gemv.
  - Switch the Hessenberg least-squares H y = e from a device
    dpnp.linalg.lstsq (which dispatches an SVD kernel for a tiny
    21x20 problem per restart) to numpy.linalg.lstsq on the host
    (audit item IntelPython#17). Matches CuPy's choice and removes a device-
    side SVD launch that on Intel GPU dominates the restart cost
    for the default restart=20. RHS e is now maintained as a numpy
    array; H is copied via dpnp.asnumpy once per restart and the
    resulting y is shipped back as a dpnp array for the V @ y
    update.
  - V[:, j+1] = v retained as a single contiguous USM slice store
    (audit item IntelPython#15 closes as no-change-required: the assignment is
    already one dpnp op on an F-order buffer and there is no fused
    'normalise-then-store' path without further binding work).
  - cg per-iter syncs collapsed from 3-4 down to 1 (audit item IntelPython#9).
    The pAp and rz_new breakdown checks are no longer transferred
    to the host on every iteration; instead the loop relies on
    IEEE-754 inf / NaN propagation through alpha = rz / pAp. When
    pAp underflows the resulting alpha is inf or NaN, poisons the
    next residual via x += alpha * p and r -= alpha * Ap, and the
    single norm sync at the top of the next iteration detects the
    breakdown via numpy.isfinite(rnorm_host) and exits with
    info > 0. Mirrors the cuBLAS-style CG inner loop (nrm2 + scalar
    test, one host barrier per iter); the initial rz breakdown
    guard remains so a zero preconditioned residual still short-
    circuits correctly.

tests/third_party/cupyx/scipy_tests/sparse_tests/test_linalg.py
  - test_gmres_complex_arnoldi_fast_path: complex-dtype regression
    guard for the conjugate-in-place branch of _make_compute_hu --
    a silent miss of the conjugate would lose orthogonality and
    misconverge.
  - test_cg_inf_breakdown_returns_positive_info: regression guard
    for the per-iter-sync collapse. Runs cg on a deliberately
    singular SPD operator and asserts info > 0 (not zero, not -1)
    so a future re-introduction of explicit breakdown syncs would
    still pass but a regression to the old info contract would not.
… __del__ against shutdown races

Closes audit items IntelPython#5 and IntelPython#29 from the prior solver review. Item IntelPython#4
(_matmat default uses a per-column matvec loop) is closed as wontfix:
SciPy's scipy.sparse.linalg.LinearOperator and cupyx's analogue both
ship the same hstack-of-matvecs default, so dpnp matches the
reference exactly and there is no portable improvement to make
without subclass-level _matmat overrides (which _CustomLinearOperator
already exposes via its matmat= constructor argument).

scipy/sparse/linalg/_interface.py
  - Set __array_ufunc__ = None on the LinearOperator base class.
    This is the SciPy contract: a host numpy.ndarray on the left of
    np_array * linop or np_array @ linop previously triggered
    NumPy's ufunc dispatch first, which would attempt to broadcast
    the operator element-wise before falling back to its reflected
    operator method -- producing either an opaque error or a wrong-
    typed result. With __array_ufunc__ = None NumPy returns
    NotImplemented from the ufunc protocol and Python's operator
    dispatch falls through cleanly to LinearOperator.__rmul__ /
    __rmatmul__. dpnp.ndarray itself sets __array_ufunc__ = None
    (see dpnp/dpnp_array.py:222) for the same reason, so the two
    dispatch systems now agree.

scipy/sparse/_csr.py, scipy/sparse/linalg/_iterative.py
  - Harden __del__ in csr_matrix and in _CachedSpMV against the
    interpreter-shutdown race where the compiled _sparse_impl
    extension is garbage-collected before the matrix instance whose
    oneMKL handle it owns. Previous code used a single
    except Exception: pass which silenced two qualitatively
    different failure modes:
      1. shutdown race -- extension gone, si._sparse_gemv_release
         evaluates to None or AttributeError; the handle is
         unrecoverable and leaving the OS to reclaim it at process
         exit is the only sane option;
      2. genuine backend error while the interpreter is healthy --
         a real bug we want to surface eventually, but raising from
         __del__ produces only an 'Exception ignored in:' warning
         and the handle is gone either way.

    The new code probes getattr(si, '_sparse_gemv_release', None)
    explicitly so case (1) takes the fast non-call path, and then
    splits the except into (AttributeError, TypeError) for case (1)-
    style residuals (queue / handle attribute access racing the
    shutdown) versus a final broad except for case (2). Both still
    return silently from __del__ -- raising is never valid here --
    but the intent is now documented and a real backend regression
    is no longer indistinguishable from the GC race in code review.

tests/third_party/cupyx/scipy_tests/sparse_tests/test_linalg.py
  - test_array_ufunc_opt_out: asserts the __array_ufunc__ = None
    marker is present on LinearOperator. Mirrors SciPy's own test
    suite test_interface.py::test_array_ufunc_opt_out.
  - test_numpy_scalar_times_linop_dispatches_to_rmul: the concrete
    runtime consequence -- numpy.float64(2.0) * linop must
    produce a scaled LinearOperator, not raise or yield an array.
Two GMRES tests asked scipy for rtol=1e-8 (real) and rtol=1e-7
(complex). For float32 / complex64 the accumulated rounding error
in classical Gram-Schmidt over n Arnoldi steps is already
O(eps * sqrt(n)) ~ 4e-7, so even the scipy reference cannot reach
those tolerances on a well-conditioned 10x10 / 12x12 system. The
failure surfaces on the very first assertion, info_ref == 0,
making it look like a dpnp regression when in fact it is a test-
design bug in our suite: the relative residual floors out at the
dtype's representable precision.

Confirmed with a host-side scipy.sparse.linalg.gmres reproduction:

  dt=<f4  n=10  rtol=1e-08  info=100  rel_resid=1.25e-08  (FAIL)
  dt=<f4  n=10  rtol=1e-05  info=  0  rel_resid=1.25e-06  (PASS)
  dt=<c8  n=12  rtol=1e-07  info=120  (FAIL on user's host; rng-sensitive)
  dt=<c8  n=12  rtol=1e-05  info=  0  rel_resid=2.55e-06  (PASS)
  dt=<f8  n=10  rtol=1e-08  info=  0                     (PASS unchanged)
  dt=<c16 n=12  rtol=1e-07  info=  0                     (PASS unchanged)

Patch: derive rtol per dtype -- 1e-5 for the single-precision
variants (float32, complex64), 1e-8 / 1e-7 for double precision.
The assert_allclose tolerances against the scipy reference also
need to be widened for the single-precision branches (5e-4 / 5e-5)
for the same noise-floor reason; tightening them would just relocate
the false failure from info==maxiter to assert_allclose. double-
precision comparisons stay at the original 1e-4 / 1e-5.

No solver code changes: both tests still serve their regression-
guard purpose. The complex test in particular still exercises the
V^T -> V^H conjugate-in-place branch of _make_compute_hu, and the
real test still drives the full GMRES restart loop end-to-end.
…ath bug

The complex GMRES Arnoldi step had a math bug since the alpha/beta
refactor in commit 50e9df4. Pass 1 issued gemv(transpose=T) to
get V^T @ u, then post-conjugated h to recover V^H @ u. The identity
that conjugation appeals to:

    conj(V^T @ u)  ==  V^H @ u

holds ONLY when u is real-valued. For complex u the post-conjugate
actually produces:

    conj(V^T @ u)  ==  V^H @ conj(u)

which is a different vector. Classical Gram-Schmidt loses
orthogonality, the Krylov basis collapses, and GMRES never reaches
the requested rtol. The bug was caught by the complex64 case of
test_gmres_complex_arnoldi_fast_path (info=120, residual ~36 instead
of converging). complex128 happened to mask the failure on some
configurations because for_dtypes iterates dtypes inside a single
test method and the complex64 assertion fires first, never giving
complex128 a chance to run. Numpy-level reproduction confirms both
single- and double-precision diverge identically with the buggy math.

Fix: extend bi._gemv_alpha_beta to accept the full {N, T, C}
tri-state of oneMKL transpose modes, so the GMRES inner step can
request V^H @ u directly in one kernel rather than emulating it.

backend/extensions/blas/gemv.{hpp,cpp}, blas_py.cpp
  - Replace the transpose: bool parameter on gemv_alpha_beta
    with trans_op: int (0=N, 1=T, 2=C). The legacy gemv()
    wrapper, used by dpnp.dot and other non-Hermitian call sites,
    keeps its bool signature and maps it to {0, 1} internally so
    no existing caller is affected.
  - Pass trans_op through gemv_dispatch into the oneMKL gemv call.
    Conjugate-transpose is restricted to F-contiguous matrices:
    the row-major remap (treating a C-contig matrix as its column-
    major transpose) does not extend cleanly to the C op because
    (A^T)^H == conj(A), which oneMKL does not expose as a gemv
    mode. Callers needing C on row-major input must F-contigify
    first; an explicit ValueError documents the constraint.
  - The cuBLAS-only branch handles trans_op=C the same way: only
    via the F-contig path.
  - Update the pybind binding name and docstring accordingly.

scipy/sparse/linalg/_iterative.py
  - _make_compute_hu picks pass1_trans_op = 2 (C) for complex
    matrices and 1 (T) for real matrices. The closure body becomes
    a clean two-gemv sequence with no scratch buffer, no in-place
    conjugate, and no special-case branching. The Krylov basis is
    F-contiguous by construction (allocated with order='F' at the
    top of gmres()), so the F-contig restriction on trans_op=C is
    automatically satisfied.
  - Drop the dpnp.tensor._tensor_elementwise_impl import that an
    intermediate WIP had pulled in for tei._conj; it's no longer
    needed now that the conjugate happens inside oneMKL.
  - Update the docstring to explain why the post-hoc conjugate
    approach is wrong and why C-mode is the correct fix, so the
    next reader does not re-introduce the bug.
Audit-driven follow-up to the LinearOperator review. _iterative.py
already follows dpnp's strict-coercion contract (inputs must be
dpnp arrays or true scalars; never numpy arrays; transfers are
explicit). _interface.py was written from the SciPy reference,
where host scalars are routine, so a few implicit-transfer paths
slipped in. This commit tightens four of them.

Fix 1 (HIGH) -- LinearOperator.dot()
  Previously called dpnp.asarray(x) on any non-scalar, non-
  LinearOperator argument. That silently host-to-device-copied a
  numpy.ndarray on every call, masking real device / queue
  selection bugs in caller code. Now rejects numpy.ndarray with a
  directed TypeError telling the user to convert via
  dpnp.asarray() explicitly; rejects any other non-dpnp non-scalar
  non-LinearOperator with a generic 'type not understood' error.
  This changes user-visible behaviour: linop @ numpy_array, which
  used to silently upload, now raises. The companion test
  test_dot_accepts_dpnp_array_after_explicit_transfer pins the
  documented workaround.

Fix 2 (MEDIUM) -- _ScaledLinearOperator.__init__ dtype inference
  Previously passed type(alpha) (a Python class object) to
  _get_dtype, which delegated to dpnp.result_type. For Python
  scalars this collapsed every float to float64, silently widening
  a float32 operator's matvec results to float64 on the device
  whenever scaled by 2.0 etc. Now uses numpy.array(alpha).dtype
  (a pure-host op that returns the smallest dtype holding alpha)
  so float32 stays float32. Regression guard:
  test_scaled_operator_preserves_float32_dtype.

Fix 3 (MEDIUM) -- _ScaledLinearOperator conjugates via numpy not dpnp
  _rmatvec, _rmatmat, _adjoint did dpnp.conj(self.args[1]) on a
  host scalar (alpha). Depending on dpnp version, dpnp.conj on a
  scalar either returns a host scalar (lucky) or promotes to a
  0-D dpnp array (one-element device upload per matvec). numpy.conj
  guarantees the host result for host scalars. The follow-up
  multiplication enters dpnp's __rmul__ chain identically to the
  unconjugated _matvec/_matmat paths, so device dispatch is
  unchanged.

Fix 7 (cosmetic) -- aslinearoperator drops dead try/except
  The numpy import there was wrapped in try/except ImportError as a
  defensive maneuver, but numpy is a hard dpnp dependency (imported
  unconditionally at module top), so the exception path was dead
  code that obscured intent. Replaced with a plain
  isinstance(A, numpy.ndarray) check.

Fix 4 (doc only) -- _PowerLinearOperator int(p) hazard comment
  int(p) inside _PowerLinearOperator.__init__ would force a device
  sync if p were a dpnp 0-D array; in practice the entry guard in
  LinearOperator.__pow__ (dpnp.isscalar(p), which is False for any
  0-D dpnp array) prevents this. Added a comment so a future
  caller-side change does not re-introduce the hazard.
@abagusetty abagusetty requested a review from antonwolfy June 1, 2026 17:30
Comment thread dpnp/backend/extensions/sparse/gemv.cpp Outdated
@@ -0,0 +1,399 @@
//*****************************************************************************
// Copyright (c) 2025, Intel Corporation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update the year to 2026 in the new files

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in the recent commits

{
#if defined(DPCTL_HAS_TYPE_DEFINED_ENTRY)
static constexpr bool
is_defined = std::disjunction dpnp_td_ns::TypeDefinedEntry<Tv, float>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Angle brackets are missing for std::disjunction<>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking a peek, cleaned-up this file

Comment thread dpnp/tests/test_scipy_sparse_linalg.py Outdated
# returns wrong solutions for complex dtypes. Complex GMRES tests are
# xfailed below. When the Givens block is fixed the xfails will flip to
# XPASS and force an update here.
_GMRES_CPX_XFAIL = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand it commit fb11515 fixes the complex GMRES convergence issue so _GMRES_CPX_XFAIL is unnecessary
At least without that and changed _GMRES_DTYPES all tests pass on my laptop

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by removing the _GMRES_CPX_XFAIL and improving stability tests

abagusetty and others added 8 commits June 10, 2026 09:09
…GMRES xfail cleanup

Drops stale xfails:
  - GMRES complex Arnoldi V^H bug was fixed in fb11515; _GMRES_CPX_XFAIL
    guard and _GMRES_DTYPES wrapper removed, parametrize switched to
    get_float_complex_dtypes() to match TestCg

Cleanup:
  - drop unused imports (get_all_dtypes, third_party.cupy.testing)
  - drop stale vvsort() comment
  - tighten (TypeError, Exception) catch-alls to specific types
  - add seed parameter to _spd_matrix for consistency with peers

Correctness:
  - _sym_indefinite: add complex Hermitian path and guarantee at
    least one negative + one positive eigenvalue in the real path
  - test_cg_matches_scipy / test_gmres_matches_scipy: parametrize
    over get_float_complex_dtypes() with dtype-aware rtol, mirroring
    the noise-floor fix from the cupyx-mirror tests; align maxiter
    between scipy reference and dpnp call in gmres
  - callback tests for cg / gmres / minres: assert argument types
    and shapes; verify (loose) monotonic decrease of the residual

Skip-pattern unification:
  - replace in-body has_support_aspect64() skips with method-level
    @pytest.mark.skipif decorators (canonical dpnp pattern, matches
    test_random.py and test_histogram.py)
  - parametrized tests that auto-filter via get_float_complex_dtypes()
    drop the manual skip entirely

Error-path coverage (scipy parity):
  - cg/gmres/minres error tests mirror scipy's test_invalid coverage:
    b length mismatch, 1-D A, non-square A, x0 length mismatch, host
    numpy.ndarray rejection
  - add empty 0x0 matrix smoke test for each solver
  - add test_cg_tol_kwarg_compat to pin the deprecated-tol alias
  - add test_gmres_restart_clamped_to_n
  - add test_gmres_x0_exact_solution and test_minres_x0_exact_solution
  - add test_minres_b_2dim

LinearOperator algebra (cupy parity):
  - new TestLinearOperatorAlgebra class covering _SumLinearOperator,
    _ProductLinearOperator, _ScaledLinearOperator,
    _PowerLinearOperator, the adjoint involution (A.H).H, and the
    aslinearoperator(csr_matrix) cached-SpMV fast path

TestSolversIntegration parametrize cleanup:
  - switch from (n, dtype) tuple-list parametrize to nested
    @pytest.mark.parametrize for cleaner Cartesian-product IDs
  - widen dtypes to get_float_complex_dtypes() for cg and gmres

Edge-case smoke tests:
  - new TestSolversEdgeCases class: identity-matrix one-iter
    correctness, wide-spectrum diagonal SPD probe, matvec-raises
    exception propagation, tiny n=1 / n=2 systems

Sibling-style alignment:
  - from dpnp.tests.helper import ... -> from .helper import ...
    (matches 36 sibling files using the relative form)
  - drop module-level 'if is_scipy_available(): import scipy_sla'
    guard in favour of @with_requires('scipy') decorators with lazy
    local imports inside each test body (matches the 94-use sibling
    idiom, e.g. test_special.py)
Co-authored-by: Anton <100830759+antonwolfy@users.noreply.github.com>
…solver-internal failures

- test_identity_system_one_iter: drop gmres; A=I triggers Arnoldi
  happy breakdown at j=1 and detecting it would require a per-step
  host sync.
- test_wide_spectrum_diagonal: tighten spectrum from cond~1e6 to
  cond~1e3 so CG converges within the 2*n iteration budget.
- test_tiny_system: replace cross-product parametrize with an
  explicit (solver, n) list that omits (gmres, n=1); n=1 GMRES
  hits oneMKL's incx!=0 requirement via dpctl's length-1 stride-0
  reporting, and the system is mathematically degenerate.
MINRES's SciPy-matching stopping test is ||r|| / (||A|| ||x||) <= rtol,
not ||r|| / ||b|| <= rtol. With ||A|| ~ 1e2 on this system, the
achieved ||r|| / ||b|| lands around 1e-5, which the prior 1e-5
assertion bound failed by a constant factor (~3x). Loosen to 1e-3,
which keeps the test as a real correctness check (CG still reaches
~1e-7 on the same input) while no longer being a slave to the
absolute Anorm scaling.
…terion

Reverts the previous loose 1e-3 ||r||/||b|| bound; asserts each
solver against its own contractual stopping criterion instead.

cg     -> ||r|| / ||b||              < 10 * rtol
minres -> ||r|| / (||A|| ||x||)      < 10 * rtol

Both bounds verified against SciPy 1.15 on the same problem:
cg stops at iter 48 with ||r||/||b|| ~ 7e-9, minres stops at
iter 40 with ||r||/(||A|| ||x||) ~ 2e-7 -- the prior 1e-5 bound
on ||r||/||b|| was unreachable for minres on this matrix
(||A||~1e2 inflates ||r||/||b|| by ~||A|| ||x||/||b||~1.4e2
relative to the criterion minres actually optimises). Avoids
SVD by using max(|diag|) for ||A||_2 of the diagonal matrix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request support for scipy.sparse.linalg LinearOperator, GMRES, and MINRES

5 participants